你好,我是大圣。
上一讲我带你手写了一个迷你的Vue compiler,还学习了编译原理的基础知识。通过实现这个迷你Vue compiler,我们知道了tokenizer可以用来做语句分析,而parse负责生成抽象语法树AST。然后我们一起分析AST中的Vue语法,最后通过generate函数生成最终的代码。
今天我就带你深入Vue的compiler源码之中,看看Vue内部到底是怎么实现的。有了上一讲编译原理的入门基础,你会对Compiler执行全流程有更深的理解。
Vue 3内部有4个和compiler相关的包。compiler-dom和compiler-core负责实现浏览器端的编译,这两个包是我们需要深入研究的,compiler-ssr负责服务器端渲染,我们后面讲ssr的时候再研究,compiler-sfc是编译.vue单文件组件的,有兴趣的同学可以自行探索。
首先我们进入到vue-next/packages/compiler-dom/index.ts文件下,在 GitHub 上你可以找到下面这段代码。
compiler函数有两个参数,第一个参数template,它是我们项目中的模板字符串;第二个参数options是编译的配置,内部调用了baseCompile函数。我们可以看到,这里的调用关系和runtime-dom、runtime-core的关系类似,compiler-dom负责传入浏览器Dom相关的API,实际编译的baseCompile是由compiler-core提供的。
我们先来看看compiler-dom做了哪些额外的配置。
首先,parserOption传入了parse的配置,通过parserOption传递的isNativeTag来区分element和component。这里的实现也非常简单,把所有html的标签名存储在一个对象中,然后就可以很轻松地判断出div是浏览器自带的element。
baseCompile传递的其他参数nodeTransforms和directiveTransforms,它们做的也是和上面代码类似的事。
然后,我们进入到baseCompile函数中,这就是Vue浏览器端编译的核心流程。
下面的代码中可以很清楚地看到,我们先通过baseParse把传递的template解析成AST,然后通过transform函数对AST进行语义化分析,最后通过generate函数生成代码。
这个主要逻辑和我们写的迷你compiler基本一致,这些函数大概要做的事你也心中有数了。这里你也能体验到,亲手实现一个迷你版本对我们阅读源码很有帮助。
接下来,我们就进入到这几个函数之中去,看一下跟迷你compiler里的实现相比,我们到底做了哪些优化。
上一讲中我们体验了Vue的在线模板编译环境,可以在console中看到Vue解析得到的AST。
如下图所示,可以看到这个AST比迷你版多了很多额外的属性。 loc用来描述节点对应代码的信息,component和directive用来记录代码中出现的组件和指令等等。
然后我们进入到baseParse函数中, 这里的createParserContext和createRoot用来生成上下文,其实就是创建了一个对象,保存当前parse函数中需要共享的数据和变量,最后调用parseChildren。
children内部开始判断<开头的标识符,判断开始还是闭合标签后,接着会生成一个nodes数组。其中,advanceBy函数负责更新context中的source用来向前遍历代码,最终对不同的场景执行不同的函数。
parseInterpolation和parseText函数的逻辑比较简单。parseInterpolation负责识别变量的分隔符 {{ 和}} ,然后通过parseTextData获取变量的值,并且通过innerStart和innerEnd去记录插值的位置;parseText负责处理模板中的普通文本,主要是把文本包裹成AST对象。
接着我们看看处理节点的parseElement函数都做了什么。首先要判断pre和v-pre标签,然后通过isVoidTag判断标签是否是自闭合标签,这个函数是从compiler-dom中传来的,之后会递归调用parseChildren,接着再解析开始标签、解析子节点,最后解析结束标签。
最后,我们来看下解析节点的parseTag函数的逻辑,匹配文本标签结束的位置后,先通过parseAttributes函数处理属性,然后对pre和v-pre标签进行检查,最后通过isComponent函数判断是否为组件。
isComponent内部会通过compiler-dom传递的isNativeTag来辅助判断结果,最终返回一个描述节点的对象,包含当前节点所有解析之后的信息,tag表示标签名,children表示子节点的数组,具体代码我放在了后面。
parse函数生成AST之后,我们就有了一个完整描述template的对象,它包含了template中所有的信息。
下一步我们要对AST进行语义化的分析。transform函数的执行流程分支很多, 核心的逻辑就是识别一个个的Vue的语法,并且进行编译器的优化,我们经常提到的静态标记就是这一步完成的。
我们进入到transform函数中,可以看到,内部通过createTransformContext创建上下文对象,这个对象包含当前分析的属性配置,包括是否ssr,是否静态提升还有工具函数等等,这个对象的属性你可以在 GitHub 上看到。
然后通过traverseNode即可编译AST所有的节点。核心的转换流程是在遍历中实现,内部使用switch判断node.type执行不同的处理逻辑。比如如果是Interpolation,就需要在helper中导入toDisplayString工具函数,这个迷你版本中我们也实现过。
transform中还会调用transformElement来转换节点,用来处理props和children的静态标记,transformText用来转换文本,这里的代码比较简单, 你可以自行在 Github 上查阅。
transform函数参数中的nodeTransforms和directiveTransforms传递了Vue中template语法的配置,这个两个函数由getBaseTransformPreset返回。
下面的代码中,transformIf和transformFor函数式解析Vue中v-if和v-for的语法转换,transformOn和transformModel是解析v-on和v-model的语法解析,这里我们只关注v-开头的语法。
然后我们再来看看transformIf的函数实现。首先判断v-if、v-else和v-else-if属性,内部通过createCodegenNodeForBranch来创建条件分支,在AST中标记当前v-if的处理逻辑。这段逻辑标记结束后,在generate中就会把v-if标签和后面的v-else标签解析成三元表达式。
transform对AST分析结束之后,我们就得到了一个优化后的AST对象,最后我们需要调用generate函数最终生成render函数。
结合下面的代码我们可以看到,generate首先通过createCodegenContext创建上下文对象,然后通过genModulePreamble生成预先定义好的代码模板,然后生成render函数,最后生成创建虚拟DOM的表达式。
我们来看下关键的步骤,genModulePreamble函数生成import风格的代码,这也是我们迷你版本中的功能:通过遍历helpers,生成import字符串,这对应了代码的第二行。
接下来的步骤就是生成渲染函数render和component的代码,最后通过genNode生成创建虚拟的代码,执行switch语句生成不同的代码,一共有十几种情况,这里就不一一赘述了。我们可以回顾上一讲中迷你代码的逻辑,总之针对变量,标签,v-if和v-for都有不同的代码生成逻辑,最终才实现了template到render函数的转化。
今天的内容到这就讲完了,我给你总结一下今天讲到的内容吧。
今天我们一起分析了Vue中的compiler执行全流程,有了上一讲编译入门知识的基础之后,今天的parse,transform和generate模块就是在上一讲的基础之上,更加全面地实现代码的编译和转化。
上面的流程图中,我们代码中的template是通过compiler函数进行编译转换,compiler内部调用了compiler-core中的baseCompile函数,并且传递了浏览器平台的转换逻辑。
比如isNativeTag等函数,baseCompie函数中首先通过baseParse函数把template处理成为AST,并且由transform函数进行标记优化,transfom内部的transformIf,transformOn等函数会对Vue中的语法进行标记,这样在generate函数中就可以使用优化后的AST去生成最终的render函数。
最终,render函数会和我们写的setup函数一起组成组件对象,交给页面进行渲染。后面我特意为你绘制了一幅Vue全流程的架构图,你可以保存下来随时查阅。
Vue源码中的编译优化也是Vue框架的亮点之一,我们自己也要思考编译器优化的机制,可以提高浏览器运行时的性能,我们项目中该如何借鉴这种思路呢?下一讲我会详细剖析编译原理在实战里的应用,敬请期待。
最后留一个思考题,transform函数中针对Vue中的语法有很多的函数处理,比如transformIf会把v-if指令编译成为一个三元表达式,请你从其余的函数选一个在评论区分享transform处理的结果吧。欢迎在评论区分享你的答案,我们下一讲再见!